[READ-ONLY] a fast, modern browser for the npm registry
at main 150 lines 5.1 kB view raw
1import type { H3Event } from 'h3' 2import * as v from 'valibot' 3import { PackageRouteParamsSchema } from '#shared/schemas/package' 4import { SkillNameSchema } from '#shared/schemas/skills' 5import type { SkillsListResponse, SkillContentResponse } from '#shared/types' 6import { 7 CACHE_MAX_AGE_ONE_HOUR, 8 CACHE_MAX_AGE_ONE_YEAR, 9 ERROR_SKILLS_FETCH_FAILED, 10 ERROR_SKILL_NOT_FOUND, 11 ERROR_SKILL_FILE_NOT_FOUND, 12} from '#shared/utils/constants' 13import { parsePackageParam } from '#shared/utils/parse-package-param' 14 15const CACHE_VERSION = 1 16 17/** 18 * Skills discovery and content endpoint. 19 * 20 * URL patterns: 21 * - /skills/vue/v/3.4.0 → discovery (list skills) 22 * - /skills/vue/v/3.4.0/my-skill → skill content (SKILL.md parsed) 23 * - /skills/vue/v/3.4.0/my-skill/refs/guide.md → supporting file (raw) 24 * - /skills/@scope/pkg/v/1.0.0 → scoped package 25 */ 26export default defineCachedEventHandler( 27 async event => { 28 const pkgParam = getRouterParam(event, 'pkg') 29 if (!pkgParam) { 30 throw createError({ statusCode: 404, message: 'Package name is required' }) 31 } 32 33 const { packageName, version: rawVersion, rest } = parsePackageParam(pkgParam) 34 35 try { 36 const validated = v.parse(PackageRouteParamsSchema, { packageName, version: rawVersion }) 37 38 let version = validated.version 39 let isVersioned = !!version 40 if (!version) { 41 const packument = await fetchNpmPackage(validated.packageName) 42 version = packument['dist-tags']?.latest 43 if (!version) { 44 throw createError({ statusCode: 404, message: 'No latest version found' }) 45 } 46 } 47 48 // Set cache headers: 1 year for versioned, 1 hour for latest 49 if (isVersioned) { 50 setHeader(event, 'Cache-Control', `public, max-age=${CACHE_MAX_AGE_ONE_YEAR}, immutable`) 51 } 52 53 if (rest.length === 0) { 54 return await handleDiscovery(validated.packageName, version) 55 } 56 57 const skillName = v.parse(SkillNameSchema, rest[0]) 58 59 if (rest.length === 1) { 60 return await handleSkillContent(validated.packageName, version, skillName) 61 } 62 63 const filePath = rest.slice(1).join('/') 64 return await handleSkillFile(event, validated.packageName, version, skillName, filePath) 65 } catch (error) { 66 handleApiError(error, { statusCode: 502, message: ERROR_SKILLS_FETCH_FAILED }) 67 } 68 }, 69 { 70 maxAge: CACHE_MAX_AGE_ONE_HOUR, 71 swr: true, 72 getKey: event => { 73 const pkg = getRouterParam(event, 'pkg') ?? '' 74 return `skills:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}` 75 }, 76 }, 77) 78 79async function handleDiscovery(packageName: string, version: string): Promise<SkillsListResponse> { 80 const fileTree = await getPackageFileTree(packageName, version) 81 const skillDirs = findSkillDirs(fileTree.tree) 82 83 if (skillDirs.length === 0) { 84 return { package: packageName, version, skills: [] } 85 } 86 87 const skills = await fetchSkillsList(packageName, version, skillDirs) 88 return { package: packageName, version, skills } 89} 90 91async function handleSkillContent( 92 packageName: string, 93 version: string, 94 skillName: string, 95): Promise<SkillContentResponse> { 96 try { 97 const { frontmatter, content } = await fetchSkillContent(packageName, version, skillName) 98 return { package: packageName, version, skill: skillName, frontmatter, content } 99 } catch (error) { 100 if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) { 101 throw createError({ statusCode: 404, message: ERROR_SKILL_NOT_FOUND }) 102 } 103 throw error 104 } 105} 106 107async function handleSkillFile( 108 event: H3Event, 109 packageName: string, 110 version: string, 111 skillName: string, 112 filePath: string, 113): Promise<string> { 114 // Validate file path to prevent directory traversal 115 if (filePath.includes('..') || filePath.startsWith('/')) { 116 throw createError({ statusCode: 400, message: 'Invalid file path' }) 117 } 118 119 // Only allow files within skill subdirectories (scripts/, references/, assets/) 120 const allowedPrefixes = ['scripts/', 'references/', 'assets/', 'refs/'] 121 if (!allowedPrefixes.some(p => filePath.startsWith(p))) { 122 throw createError({ 123 statusCode: 400, 124 message: 'File must be in scripts/, references/, or assets/ subdirectory', 125 }) 126 } 127 128 try { 129 const content = await fetchSkillFile(packageName, version, `skills/${skillName}/${filePath}`) 130 131 const ext = filePath.split('.').pop()?.toLowerCase() || '' 132 const contentTypes: Record<string, string> = { 133 md: 'text/markdown', 134 txt: 'text/plain', 135 json: 'application/json', 136 js: 'text/javascript', 137 ts: 'text/typescript', 138 sh: 'text/x-shellscript', 139 py: 'text/x-python', 140 } 141 setHeader(event, 'Content-Type', contentTypes[ext] || 'text/plain') 142 143 return content 144 } catch (error) { 145 if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) { 146 throw createError({ statusCode: 404, message: ERROR_SKILL_FILE_NOT_FOUND }) 147 } 148 throw error 149 } 150}